在完成了 Config.gs 全局設定檔與 Telegram Bot API 連線測試後,今天我們進入系統後端最核心的業務邏輯層——BookingService.gs (預約服務模組)。
本模組嚴格遵循昨日訂定的 SRD (系統需求說明書) 規格,實作預約時段查詢、時間衝突檢測算法(Interval Overlap Detection)、新預約建立與 Telegram 即時推播通知。
在處理房間與資源預約時,最關鍵的要求是「嚴防重覆預約 (Double Booking)」。
兩個時間區間 A = [StartA, EndA) 與 B = [StartB, EndB) 發生時間重疊的充要條件為:
Overlap = (StartA < EndB) ^ (EndA > StartB)
只要資料庫中存在任意一筆狀態為 APPROVED 的預約紀錄符合上述條件,該預約請求即視為時間衝突,系統將拒絕寫入並回傳錯誤訊息。
BookingService.gs)請在 Google Apps Script 專案中新增名為 BookingService.gs 的檔案,並寫入以下程式碼:
/**
* ==============================================================================
* 後端核心預約邏輯 API (BookingService.gs)
* 專案:NGO 房間與資源預約管理系統
* 說明:實作可預約時段查詢、時間衝突檢測算法、提交新預約與 Telegram 推播整合。
* ==============================================================================
*/
/**
* 取得指定房間在特定日期的所有可用與已預約時段清單
* @param {string} dateStr - 查詢日期 (格式: YYYY-MM-DD)
* @param {string} roomId - 房間代號 (例如: "A1", "TALK_ROOM")
* @returns {Object} 包含當日所有時段狀態與元資料
*/
function getAvailableSlots(dateStr, roomId) {
const config = getConfig();
const spreadsheet = SpreadsheetApp.openById(config.SPREADSHEET_ID);
const sheet = spreadsheet.getSheetByName("Bookings");
if (!sheet) {
throw new Error("❌ 資料庫找不到 Bookings 工作表,請檢查 Sheet 設定。");
}
// 1. 產生當天營業時間內的所有 30 分鐘時間區間
const allSlots = generateDaySlots(dateStr, config.WORK_START_TIME, config.WORK_END_TIME, config.SLOT_DURATION_MINUTES);
// 2. 取得資料庫中該房間且未取消的預約紀錄
const data = sheet.getDataRange().getValues();
const headers = data.shift() || [];
const colIndex = {
roomId: headers.indexOf("room_id"),
startTime: headers.indexOf("start_time"),
endTime: headers.indexOf("end_time"),
status: headers.indexOf("status")
};
const existingBookings = data.filter(row => {
const status = row[colIndex.status];
const rId = row[colIndex.roomId];
const isSameRoom = (rId === roomId);
const isActiveStatus = (status === "APPROVED");
// 判斷是否為同日預約
const bookingDateStr = Utilities.formatDate(new Date(row[colIndex.startTime]), "Asia/Hong_Kong", "yyyy-MM-dd");
const isSameDate = (bookingDateStr === dateStr);
return isSameRoom && isActiveStatus && isSameDate;
}).map(row => ({
startTime: new Date(row[colIndex.startTime]).getTime(),
endTime: new Date(row[colIndex.endTime]).getTime()
}));
// 3. 標記各個時段是否已被預約
const processedSlots = allSlots.map(slot => {
const slotStart = slot.startTime.getTime();
const slotEnd = slot.endTime.getTime();
// 檢查是否有任何預約與此 Slot 重疊
const isBooked = existingBookings.some(b => (slotStart < b.endTime && slotEnd > b.startTime));
return {
timeLabel: slot.timeLabel, // e.g. "09:00 - 09:30"
startTimeIso: slot.startTime.toISOString(),
endTimeIso: slot.endTime.toISOString(),
isAvailable: !isBooked
};
});
return {
success: true,
date: dateStr,
roomId: roomId,
slots: processedSlots
};
}
/**
* 提交新預約 (包含 90 天極限驗證、時間衝突檢測與 Telegram 推播)
* @param {string} userEmail - 預約職工 Email
* @param {string} roomId - 房間代號
* @param {string} startTimeIso - 預約開始時間 (ISO 格式)
* @param {string} endTimeIso - 預約結束時間 (ISO 格式)
* @param {string} purpose - 預約用途說明
* @returns {Object} 處理結果與預約唯一 ID
*/
function submitNewBooking(userEmail, roomId, startTimeIso, endTimeIso, purpose) {
const config = getConfig();
const now = new Date();
const startTime = new Date(startTimeIso);
const endTime = new Date(endTimeIso);
// 1. 基礎輸入驗證
if (startTime >= endTime) {
return { success: false, message: "❌ 預約失敗:結束時間必須晚於開始時間。" };
}
if (startTime < now) {
return { success: false, message: "❌ 預約失敗:無法預約過去的時間時段。" };
}
// 2. 驗證預約天數上限 (MAX_BOOKING_ADVANCE_DAYS = 90 天)
const maxAllowedDate = new Date();
maxAllowedDate.setDate(now.getDate() + config.MAX_BOOKING_ADVANCE_DAYS);
if (startTime > maxAllowedDate) {
return { success: false, message: `❌ 預約失敗:僅開放預約未來的 ${config.MAX_BOOKING_ADVANCE_DAYS} 天內時段。` };
}
// 3. 時間衝突檢測算法 (Interval Overlap Detection)
const spreadsheet = SpreadsheetApp.openById(config.SPREADSHEET_ID);
const sheet = spreadsheet.getSheetByName("Bookings");
const data = sheet.getDataRange().getValues();
const headers = data.shift() || [];
const colIndex = {
roomId: headers.indexOf("room_id"),
startTime: headers.indexOf("start_time"),
endTime: headers.indexOf("end_time"),
status: headers.indexOf("status")
};
const reqStartMs = startTime.getTime();
const reqEndMs = endTime.getTime();
const hasCollision = data.some(row => {
const status = row[colIndex.status];
const rId = row[colIndex.roomId];
if (rId === roomId && status === "APPROVED") {
const existStartMs = new Date(row[colIndex.startTime]).getTime();
const existEndMs = new Date(row[colIndex.endTime]).getTime();
// 區間重疊判定公式
return (reqStartMs < existEndMs && reqEndMs > existStartMs);
}
return false;
});
if (hasCollision) {
return { success: false, message: "⚠️ 預約失敗:該時段已被其他職工預約,請選擇其他時段。" };
}
// 4. 計算節數與預計租用費用
const durationMinutes = (reqEndMs - reqStartMs) / (1000 * 60);
const totalSlots = durationMinutes / config.SLOT_DURATION_MINUTES;
const estimatedFee = totalSlots * config.FEE_PER_SLOT;
// 5. 寫入資料庫 (Google Sheets)
const bookingId = "BK_" + new Date().getTime();
const createdAt = new Date();
sheet.appendRow([
bookingId,
roomId,
userEmail,
startTime,
endTime,
"APPROVED",
"NOT_CHECKED_IN",
purpose,
createdAt,
"" // admin_note
]);
// 6. 觸發 Telegram 即時推播至管理員群組
const formattedStart = Utilities.formatDate(startTime, "Asia/Hong_Kong", "yyyy-MM-dd HH:mm");
const formattedEnd = Utilities.formatDate(endTime, "Asia/Hong_Kong", "HH:mm");
const telegramMessage =
`📢 <b>【新房間預約成功通知】</b>\n` +
`--------------------------------------\n` +
`🆔 <b>預約編號:</b><code>${bookingId}</code>\n` +
`🏢 <b>預約房間:</b>${roomId}\n` +
`👤 <b>預約職工:</b>${userEmail}\n` +
`⏰ <b>時段:</b>${formattedStart} - ${formattedEnd} (${totalSlots} 節)\n` +
`💰 <b>預計費用:</b>HKD $${estimatedFee}\n` +
`📝 <b>用途說明:</b>${purpose}\n` +
`--------------------------------------\n` +
`<i>系統已自動完成時間衝突排查並登記入庫。</i>`;
sendTelegramNotification(telegramMessage);
return {
success: true,
bookingId: bookingId,
message: "✅ 預約成功!已發送系統推播通知。",
estimatedFee: estimatedFee
};
}
/**
* 內部輔助函式:根據營業時間切分 30 分鐘時間節
*/
function generateDaySlots(dateStr, startTimeStr, endTimeStr, durationMinutes) {
const slots = [];
const start = new Date(`${dateStr}T${startTimeStr}:00`);
const end = new Date(`${dateStr}T${endTimeStr}:00`);
let current = new Date(start.getTime());
while (current < end) {
const slotStart = new Date(current.getTime());
const slotEnd = new Date(current.getTime() + durationMinutes * 60 * 1000);
if (slotEnd > end) break;
const startLabel = Utilities.formatDate(slotStart, "Asia/Hong_Kong", "HH:mm");
const endLabel = Utilities.formatDate(slotEnd, "Asia/Hong_Kong", "HH:mm");
slots.push({
timeLabel: `${startLabel} - ${endLabel}`,
startTime: slotStart,
endTime: slotEnd
});
current = slotEnd;
}
return slots;
}
你可以直接在 GAS 編輯器中執行以下測試函式,驗證時間衝突與預約寫入功能:
/**
* Day 19 核心功能模擬測試函式
*/
function testBookingServiceFlow() {
Logger.log("=== 開始 Day 19 核心預約 API 流程測試 ===");
const testUser = "worker@ngo.org";
const testRoom = "A1";
// 設定測試日期為明天
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = Utilities.formatDate(tomorrow, "Asia/Hong_Kong", "yyyy-MM-dd");
const startIso = `${dateStr}T10:00:00.000Z`;
const endIso = `${dateStr}T11:00:00.000Z`;
// 1. 測試第一筆預約寫入 (預期:成功)
Logger.log(`1. 嘗試提交第一筆預約 (10:00 - 11:00)...`);
const result1 = submitNewBooking(testUser, testRoom, startIso, endIso, "青少年小組聚會活動");
Logger.log(JSON.stringify(result1));
// 2. 測試重疊時段預約 (預期:衝突失敗)
const overlapStartIso = `${dateStr}T10:30:00.000Z`;
const overlapEndIso = `${dateStr}T11:30:00.000Z`;
Logger.log(`2. 嘗試提交重疊時間預約 (10:30 - 11:30)...`);
const result2 = submitNewBooking("another_worker@ngo.org", testRoom, overlapStartIso, overlapEndIso, "跨部門個案會議");
Logger.log(JSON.stringify(result2));
// 3. 測試時段查詢 API
Logger.log(`3. 查詢 ${dateStr} 房間 ${testRoom} 當日可用時段...`);
const slotsInfo = getAvailableSlots(dateStr, testRoom);
Logger.log(`總時段數: ${slotsInfo.slots.length}`);
}
今天我們實作了 BookingService.gs 後端服務,解決了時間衝突檢測算法、90 天預約上限攔截、費用自動計算,以及 Telegram 即時推播通知。
明天繼續